a73x

web/src/routes/hosts/[id]/+page.svelte

Ref:   Size: 6.7 KiB   History

<script lang="ts">
	import { page } from '$app/state';
	import {
		fleet,
		action,
		decommissionHost,
		upgradeAgent,
		upgradeStuck,
		upgradeStuckNote,
		vmsForHost,
		vmPhase,
		vmPower,
		vmIP,
		hostStatusLabel,
		formatUptime
	} from '$lib/fleet.svelte';
	import ResourceBar from '$lib/ResourceBar.svelte';

	const id = $derived(page.params.id);
	const host = $derived(fleet.hosts.find((h) => h.id === id));
	const vms = $derived(id ? vmsForHost(id) : []);

	async function decommission() {
		if (!host) return;
		if (!confirm(`Decommission host ${host.name}? Its VMs will be drained and removed.`)) return;
		const hostId = host.id;
		await action(() => decommissionHost(hostId));
	}

	async function upgrade() {
		if (!host) return;
		if (!confirm(`Upgrade ${host.name} to ${fleet.latest_version}?`)) return;
		await action(() => upgradeAgent(host.id));
	}
</script>

<p><a href="/">← fleet</a></p>

{#if !host}
	<p class="hint">Host not found (it may have been decommissioned).</p>
{:else}
	<h2>{host.name}</h2>

	<div class="columns">
		<div class="facts">
			<div class="scroll">
				<table class="kv">
					<tbody>
						<tr><th>ID</th><td>{host.id}</td></tr>
						<tr><th>Status</th><td><span class="dot {host.online ? 'on' : 'off'}"></span>{hostStatusLabel(host)}</td></tr>
						<tr><th>OS</th><td>{host.os_pretty || host.os} ({host.arch})</td></tr>
						<tr>
							<th>Agent</th>
							<td>
								{host.agent_version || '—'}
								<!-- One cell, three readings. An offer standing means the click
								     landed and the work is under way, so the button steps aside
								     rather than inviting a second click that changes nothing.
								     Once the offer is old on a host that is plainly reporting,
								     the same cell says so at length: the failure is on the host
								     and only its journal knows why. -->
								{#if host.pending_upgrade}
									{#if upgradeStuck(host)}
										<p class="stuck">{upgradeStuckNote(host)}</p>
									{:else}
										<span class="pending">upgrading → {host.pending_upgrade.version}…</span>
									{/if}
								{:else if host.agent_update_available}
									<button class="ghost upgrade" onclick={upgrade}>↑ {fleet.latest_version}</button>
								{/if}
							</td>
						</tr>
						<tr><th>Kernel</th><td>{host.kernel || '—'}</td></tr>
						<tr><th>CPU</th><td>{host.cpu_model || '—'}</td></tr>
						<tr><th>Virtualization</th><td>{host.virt || '—'}</td></tr>
						<tr><th>Provisioner</th><td>{host.provisioner}</td></tr>
						<tr>
							<th>Guest network</th>
							<td>
								{#if host.bridge_cidr}
									{host.bridge_cidr}
								{:else}
									<span class="hint">not yet reported</span>
								{/if}
							</td>
						</tr>
						<tr><th>Capacity</th><td>{host.capacity.vcpus}c / {host.capacity.mem_mb}MB / {host.capacity.disk_gb}GB</td></tr>
						<tr><th>Enrolled</th><td>{host.enrolled_at}</td></tr>
					</tbody>
				</table>
			</div>
		</div>

		<div class="doing">
			<div class="actions">
				{#if host.status !== 'decommissioning'}
					<button class="danger" onclick={decommission}>Decommission host</button>
				{:else}
					<span class="hint">Decommissioning—draining {vms.length} VM(s)…</span>
				{/if}
			</div>

			<h2>Resources</h2>
			<div class="resources">
				<ResourceBar label="vCPU" used={host.allocated.vcpus} total={host.capacity.vcpus} />
				<ResourceBar label="Memory" used={host.allocated.mem_mb} total={host.capacity.mem_mb} unit="MB" />
				<ResourceBar label="Disk" used={host.allocated.disk_gb} total={host.capacity.disk_gb} unit="GB" />
			</div>
			<p class="note">
				Allocated = sum of live VM specs. Capacity is reported by the agent (shown when online). All
				three are limits at placement: a create that would put any of them over capacity is refused.
			</p>

			{#if host.online && host.metrics}
				<h2>Host metrics (measured)</h2>
				<div class="scroll">
					<table class="kv">
						<tbody>
							<tr><th>Uptime</th><td>{formatUptime(host.metrics.uptime_s)}</td></tr>
							<tr><th>Load avg</th><td>{host.metrics.load1.toFixed(2)} / {host.metrics.load5.toFixed(2)} / {host.metrics.load15.toFixed(2)}</td></tr>
							<tr><th>Memory used</th><td>{host.metrics.mem_used_mb}MB used · {host.metrics.mem_available_mb}MB available</td></tr>
							<tr><th>Disk used</th><td>{host.metrics.disk_used_gb}GB used · {host.metrics.disk_free_gb}GB free</td></tr>
						</tbody>
					</table>
				</div>
				<p class="note">
					Measured off the host—distinct from the allocated bars above, which sum committed VM specs (quotas).
				</p>
			{/if}
		</div>
	</div>

	<!-- The VM list is a seven-column table and takes the full measure below
	     both columns, as the VM page's console does. -->
	<h2>VMs on this host ({vms.length})</h2>
	{#if vms.length === 0}
		<p class="hint">None.</p>
	{:else}
		<table>
			<thead><tr><th>Name</th><th>vCPU</th><th>Mem</th><th>Disk</th><th>Phase</th><th>Power</th><th>IP</th></tr></thead>
			<tbody>
				{#each vms as v (v.id)}
					<tr>
						<td><a href="/vms/{v.id}">{v.name}</a></td>
						<td>{v.vcpus}</td>
						<td>{v.mem_mb}MB</td>
						<td>{v.disk_gb}GB</td>
						<td>{vmPhase(v)}</td>
						<td>{vmPower(v)}</td>
						<td>{vmIP(v)}</td>
					</tr>
				{/each}
			</tbody>
		</table>
	{/if}
{/if}

<style>
	h2 {
		font-size: inherit;
	}
	/* Given the width, the page splits the way a VM's does. What this host IS
	   reads down the left—the identity it enrolled with and the machine it
	   turned out to be. What it is doing right now, and what you can do about
	   it, stands on the right: decommission, then how full it is and how it is
	   coping. The .columns grid and the .scroll boxes it holds are the shared
	   two-column vocabulary (:global in +layout.svelte); only this page-local
	   nudge stays here. */
	@media (min-width: 1100px) {
		/* Both columns start on the same line as the table's first rule. */
		.doing .actions {
			margin-top: 0.5em;
		}
	}
	.actions {
		display: flex;
		align-items: center;
		gap: 0.5em;
		margin-top: 0.8em;
	}
	.resources {
		max-width: 60ch;
	}
	.note {
		color: var(--faint);
		max-width: 72ch;
		margin: 0.3em 0 1em;
	}
	.upgrade {
		margin-left: 0.5em;
	}
	/* An upgrade in flight is a fact about the next few seconds, not a control:
	   it sits where the button was, in the faint the rest of the page uses for
	   things that are merely true. */
	.pending {
		margin-left: 0.5em;
		color: var(--faint);
	}
	/* A stuck one is the exception—it is the only thing on this page telling an
	   operator that something they did failed—so it takes its own line and the
	   colour errors already speak in. No new colour, just the loud one. */
	.stuck {
		color: var(--bad);
		max-width: 60ch;
		margin: 0.3em 0 0;
	}
</style>